package org.io4j.filesystem;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;

import org.io4j.data.DataTransferException;
import org.io4j.util.Argument;

/**
 * Represents an existing directory in the file system. Use {@link Path} to
 * represent a location of non-existing directory.
 * 
 * @see Path
 */
public class Directory extends FileSystemObject implements Iterable<Path>, Comparable<Directory> {

	/**
	 * Constructs a new {@code Directory} from a {@code Path} location of an
	 * <em>existing</em> directory.
	 * <p>
	 * To physically create a new directory in the file system from non-existing
	 * path use:
	 * 
	 * <pre>
	 * Directory directory = Directory.create(Path path);
	 * </pre>
	 * 
	 * @param path
	 *           An existing directory location in the file system
	 * @throws IllegalArgumentException
	 *            if path is {@code null} or empty
	 * @throws IllegalArgumentException
	 *            if path doesn't exist or denotes an existing file
	 * @see Path
	 */
	public Directory(Path path) {
		super(path);
		Argument.is(path.exists(), "Directory " + path
				+ " doesn't exist. Use 'Directory.create()' instead to create it.");
		checkIfDirectoryPath(path);
	}

	private static void checkIfDirectoryPath(Path path) {
		Argument.isNot(path.isFile(), path + " is a file. Use 'new File(path)' instead.");
	}

	/**
	 * Constructs a new {@code Directory} from a {@code java.io.File} location of
	 * an <em>existing</em> directory.
	 * <p>
	 * To physically create a new directory in the file system from non-existing
	 * path use:
	 * 
	 * <pre>
	 * Directory directory = Directory.create(java.io.File path);
	 * </pre>
	 * 
	 * @param path
	 *           An existing directory location in the file system
	 * @throws IllegalArgumentException
	 *            if path is {@code null} or empty
	 * @throws IllegalArgumentException
	 *            if path doesn't exist or denotes an existing file
	 * @see Path
	 */
	public Directory(java.io.File path) {
		this(new Path(path));
	}

	/**
	 * Constructs a new {@code Directory} from a {@code String} location of an
	 * <em>existing</em> directory.
	 * <p>
	 * To physically create a new directory in the file system from non-existing
	 * path use:
	 * 
	 * <pre>
	 * Directory directory = Directory.create(String path);
	 * </pre>
	 * 
	 * @param path
	 *           An existing directory location in the file system
	 * @throws IllegalArgumentException
	 *            if path is {@code null} or empty
	 * @throws IllegalArgumentException
	 *            if path doesn't exist or denotes an existing file
	 * @see Path
	 */
	public Directory(String path) {
		this(new Path(path));
	}

	/**
	 * Physically creates a new directory in the file system in location
	 * determined by {@code Path} provided if it does not exist. Returns a
	 * {@code Directory} representing existing directory otherwise.
	 * <p>
	 * It also creates any non-existing parent directories specified in the
	 * {@code Path}.
	 * 
	 * @param path
	 *           Path in the file system where the new directory will be created
	 * @return Created or existing directory
	 * @throws IllegalArgumentException
	 *            if path is {@code null}, empty or denotes an existing file
	 * @throws FileSystemException
	 *            if new directory or non-existing parent directories could not
	 *            be created
	 * @see Directory#Directory(Path)
	 * @see File#create(Path)
	 */
	public static Directory create(Path path) {
		Argument.isNotNull(path, "Path is null");
		Argument.isNot(path.isEmpty(), "Path is empty");
		if (path.exists()) {
			checkIfDirectoryPath(path);
			return new Directory(path);
		}
		ensureDirectoryExists(path.getParent());

		java.io.File file = path.toFile();
		if (!file.mkdir()) {
			throw new FileSystemException("Directory '" + path + "' could not be created.");
		}
		return new Directory(path);
	}

	/**
	 * Physically creates a new directory in the file system in location
	 * determined by the path provided if it does not exist. Returns a
	 * {@code Directory} representing existing directory otherwise.
	 * <p>
	 * It also creates any non-existing parent directories specified in the path.
	 * 
	 * @param path
	 *           Path in the file system where the new directory will be created
	 * @return Created or existing directory
	 * @throws IllegalArgumentException
	 *            if path is {@code null}, empty or denotes an existing file
	 * @throws FileSystemException
	 *            if new directory or non-existing parent directories could not
	 *            have been created
	 * @see Directory#Directory(java.io.File)
	 * @see File#create(java.io.File)
	 */
	public static Directory create(java.io.File path) {
		Argument.isNotNull(path, "Path is null");
		Argument.isNot(path.getPath().isEmpty(), "Path is empty");
		return create(new Path(path));
	}

	/**
	 * Physically creates a new directory in the file system in location
	 * determined by the path provided if one does not exist yet. Returns a
	 * {@code Directory} representing existing directory otherwise.
	 * <p>
	 * It also creates any non-existing parent directories specified in the path.
	 * 
	 * @param path
	 *           Path in the file system where the new directory will be created
	 * @return Created or existing directory
	 * @throws IllegalArgumentException
	 *            if path is {@code null}, empty or denotes an existing file
	 * @throws FileSystemException
	 *            if new directory or non-existing parent directories could not
	 *            be created
	 * @see Directory#Directory(String)
	 * @see File#create(String)
	 */
	public static Directory create(String path) {
		Argument.isNotEmpty(path, "Path is null or empty");
		return create(new Path(path));
	}

	/**
	 * Creates a new directory with a given name within this directory if it does
	 * not exist. Returns {@code Directory} representing existing directory
	 * otherwise.
	 * 
	 * @param name
	 *           The name of the directory to create
	 * @return Newly created or existing directory
	 * @throws IllegalArgumentException
	 *            if name is {@code null} or empty
	 * @throws FileSystemException
	 *            if directory could not have been created
	 */
	public Directory createDirectory(String name) {
		Argument.isNotEmpty(name, "Directory name is null or empty");
		return create(new Path(getPath(), name));
	}

	/**
	 * Creates a new file with a given name within this directory if it does not
	 * exist. Returns {@code File} representing existing file otherwise.
	 * 
	 * @param name
	 *           The name of the file to create
	 * @return Newly created or existing file
	 * @throws IllegalArgumentException
	 *            if name is {@code null} or empty
	 * @throws FileSystemException
	 *            if file could not have been created
	 */
	public File createFile(String name) {
		Argument.isNotEmpty(name, "File name is null or empty");
		return File.create(new Path(getPath(), name));
	}

	/**
	 * Moves this directory to a location in the file system determined by the
	 * destination {@code Path}.
	 * <p>
	 * Note that the original {@code Directory} won't exist after this operation
	 * (unless new location is the same as the original one). Use a returned
	 * {@code Directory} to reference the directory after it has been moved to a
	 * new location.
	 * 
	 * @param destinationPath
	 *           A location in the file system where the directory is to be moved
	 *           to
	 * @return A {@code Directory} that has been moved
	 * @throws IllegalArgumentException
	 *            if destination path is {@code null}
	 * @throws FileSystemException
	 *            if the directory could not be moved
	 */
	public Directory move(Path destinationPath) {
		Argument.isNotNull(destinationPath, "Destination path is null");
		if (destinationPathEqualsCurrent(destinationPath)) {
			return this;
		}
		doMove(destinationPath);
		return new Directory(destinationPath);
	}

	/**
	 * Moves this directory into within the specified {@code Directory}.
	 * <p>
	 * Note that the original {@code Directory} won't exist after this operation
	 * (unless new location is the same as the original one). Use a returned
	 * {@code Directory} to reference the directory after it has been moved to a
	 * new location.
	 * 
	 * @param destinationDirectory
	 *           A {@code Directory} to move the directory into
	 * @return A {@code Directory} that has been moved
	 * @throws IllegalArgumentException
	 *            if destination directory is {@code null}
	 * @throws FileSystemException
	 *            if the directory could not be moved
	 */
	@Override
	public Directory move(Directory destinationDirectory) {
		Argument.isNotNull(destinationDirectory, "Destination directory is null");
		return (Directory) super.move(destinationDirectory);
	}

	/**
	 * Copies this directory (source directory) into a destination location
	 * determined by the {@code Path} (destination path) in the file system. If
	 * the destination directory (as determined by the destination path) does not
	 * exist it is created.
	 * <p>
	 * All directories (subdirectories) and files that the source directory
	 * contains are recursively copied to the destination directory. If any
	 * directories or files exist within the destination directory prior to this
	 * operation, they are not deleted. File contents of previously existing
	 * files within the destination directory may be replaced with contents
	 * copied from the source directory.
	 * 
	 * @param destinationPath
	 *           A location in the file system where the directory is to be
	 *           copied to
	 * @return A {@code Directory} that is a copy of the source directory
	 * @throws IllegalArgumentException
	 *            if destination path is {@code null} or does not denote a
	 *            directory
	 * @throws FileSystemException
	 *            if destination directory could not be created
	 * @throws FileSystemException
	 *            if file system objects that this directory contains could not
	 *            be copied to the destination directory
	 * @see Directory#copy(Directory)
	 */
	public Directory copy(Path destinationPath) {
		Argument.isNotNull(destinationPath, "Destination path is null");
		checkIfDestinationPathIsADirectory(destinationPath);
		if (destinationPathEqualsCurrent(destinationPath)) {
			return this;
		}

		Directory destinationDirectory = Directory.create(destinationPath);
		copyContents(this, destinationDirectory);
		return destinationDirectory;
	}

	private static void checkIfDestinationPathIsADirectory(Path destinationPath) {
		Argument.isNot(destinationPath.isFile(),
				String.format("Destination path '%s' is not a directory", destinationPath));
	}

	private static void copyContents(Directory sourceDirectory, Directory destinationDirectory) {
		try {
			for (Directory subDirectory : sourceDirectory.listDirectories()) {
				subDirectory.copy(destinationDirectory);
			}
			for (File file : sourceDirectory.listFiles()) {
				file.copy(destinationDirectory);
			}
		} catch (DataTransferException e) {
			throw new FileSystemException(String.format(
					"Contents of directory '%s' could not be copied to '%s'", sourceDirectory,
					destinationDirectory), e);
		}
	}

	/**
	 * Copies this directory (source directory) into within a destination
	 * directory.
	 * <p>
	 * All directories (subdirectories) and files that the source directory
	 * contains are recursively copied to the destination directory. If any
	 * directories or files exist within the destination directory prior to this
	 * operation, they are not deleted. File contents of previously existing
	 * files within the destination directory may be replaced with contents
	 * copied from the source directory.
	 * 
	 * @param destinationDirectory
	 *           A directory in the file system where the source directory is to
	 *           be copied into
	 * @return A {@code Directory} that is a copy of the source directory
	 * @throws IllegalArgumentException
	 *            if destination path is {@code null}
	 * @throws FileSystemException
	 *            if directories and files that the source directory contains
	 *            could not be copied into the destination directory
	 * @see Directory#copy(Path)
	 */
	public Directory copy(Directory destinationDirectory) {
		Argument.isNotNull(destinationDirectory, "Destination directory is null");
		return copy(new Path(destinationDirectory.getPath(), getName()));
	}

	/**
	 * Tests if this directory is empty (doesn't contain any file system
	 * objects).
	 * 
	 * @return {@code true} if this directory is empty; {@code false} otherwise
	 */
	public boolean isEmpty() {
		return toFile().list().length == 0;
	}

	/**
	 * Tests if this directory contains the specified {@code File}.
	 * 
	 * @param file
	 *           A file to test for
	 * @return {@code true} if this directory contains the specified file;
	 *         {@code false} otherwise
	 * @throws IllegalArgumentException
	 *            if file is {@code null}
	 */
	public boolean contains(File file) {
		Argument.isNotNull(file, "File is null");
		return contains((FileSystemObject) file);
	}

	/**
	 * Tests if this directory contains the specified {@code Directory}.
	 * 
	 * @param directory
	 *           A directory to test for
	 * @return {@code true} if this directory contains the specified directory;
	 *         {@code false} otherwise
	 * @throws IllegalArgumentException
	 *            if directory is {@code null}
	 */
	public boolean contains(Directory directory) {
		Argument.isNotNull(directory, "Directory is null");
		return contains((FileSystemObject) directory);
	}

	boolean contains(FileSystemObject fileSystemObject) {
		return list().contains(fileSystemObject.getPath());
	}

	/**
	 * Lists paths of all file system objects this directory contains.
	 * 
	 * @return A {@code Collection} of {@code Path}s representing file system
	 *         objects
	 */
	public Collection<Path> list() {
		Collection<Path> list = new ArrayList<Path>();
		for (String name : toFile().list()) {
			list.add(new Path(getPath(), name));
		}
		return list;
	}

	/**
	 * Lists subdirectories this directory contains.
	 * 
	 * @return A {@code Collection} of {@code Directory}s contained within this
	 *         directory
	 */
	public Collection<Directory> listDirectories() {
		Collection<Directory> directories = new ArrayList<Directory>();
		for (Path path : list()) {
			if (path.isDirectory()) {
				directories.add(new Directory(path));
			}
		}
		return directories;
	}

	/**
	 * Lists files this directory contains.
	 * 
	 * @return A {@code Collection} of {@code File}s contained within this
	 *         directory
	 */
	public Collection<File> listFiles() {
		Collection<File> files = new ArrayList<File>();
		for (Path path : list()) {
			if (path.isFile()) {
				files.add(new File(path));
			}
		}
		return files;
	}

	/**
	 * Returns an iterator over the contents of this directory.
	 * 
	 * @return {@code Iterator} over directories and files that this directory
	 *         contains, represented as {@code Path}s
	 */
	public Iterator<Path> iterator() {
		return list().iterator();
	}

	/**
	 * Recursively deletes all file system objects from within this directory.
	 * 
	 * @return {@code true} if directory has been cleaned up successfully,
	 *         {@code false} otherwise
	 */
	public boolean clean() {
		File file = null;
		Directory directory = null;
		for (Path path : list()) {
			if (path.isDirectory()) {
				directory = new Directory(path);
				if (!directory.delete()) {
					return false;
				}
			} else {
				file = new File(path);
				if (!file.delete()) {
					return false;
				}
			}
		}
		return true;
	}

	/**
	 * Deletes this directory from the file system after recursively deleting all
	 * file system objects it contains.
	 * 
	 * @return {@code true} if directory has been deleted successfully,
	 *         {@code false} otherwise
	 */
	public boolean delete() {
		if (!clean()) {
			return false;
		}
		return toFile().delete();
	}

	/**
	 * Compares this directory with the other directory by comparing their paths
	 * lexicographically. Paths are compared case-sensitively on UNIX operating
	 * systems but case-insensitively on Microsoft Windows operating systems.
	 * 
	 * @param otherDirectory
	 *           The directory to be compared with
	 * @return zero if this directory's path is equal to the other directory's
	 *         path, a value less than zero if this directory's path is
	 *         lexicographically less than the other directory's path or a value
	 *         greater than zero if this directory's path is lexicographically
	 *         greater than the other directory's path
	 * @throws IllegalArgumentException
	 *            if other directory is {@code null}
	 */
	public int compareTo(Directory otherDirectory) {
		Argument.isNotNull(otherDirectory, "Other directory is null");
		return super.compareTo(otherDirectory);
	}
}
